State Categories and Design Rules
Understanding different types of state helps you choose the right management approach.
Local UI State
Ephemeral, view-specific (e.g., isExpanded, current tab index, text field controllers). Keep with setState in the smallest widget that needs it.
Shared UI State
Used by sibling widgets or multiple levels (e.g., selected item in a list). Lift state to the nearest common ancestor or use a scoped solution (InheritedWidget, Provider).
App/Business State
Domain data and async operations (user session, cached lists). Manage with a dedicated state layer (Provider/ChangeNotifier, Riverpod, Bloc) and keep UI code thin.
Rule: Minimize global mutable state; keep side effects out of build() and centralize business logic in services that can be unit-tested.
setState: Correct Usage and Patterns
Using setState correctly is fundamental to Flutter development.
Best Practices
- Use setState for updating local State fields only. Call
setState(() { /* mutate state */ });and keep the mutation inside the callback. - Minimize the amount of work inside setState; compute values before calling it when possible.
- Avoid setState in async callbacks after the widget is disposed; guard with
if (!mounted) return;before calling setState.
Good Pattern
void _increment() {
setState(() {
_count++;
});
}
Anti-Patterns to Avoid
- Holding large data models inside a deeply nested widget and calling setState frequently at high levels.
- Performing heavy I/O inside setState or build.
- Mutating objects without notifying listeners when using observable patterns.
InheritedWidget and InheritedModel (Lightweight Propagation)
InheritedWidget provides efficient propagation of values down the widget tree.
Usage
- Use InheritedWidget to provide immutable values down the tree efficiently; consumers use
context.dependOnInheritedWidgetOfExactType<MyInherited>()and rebuild when the InheritedWidget updates. - Use InheritedModel when widgets depend on different "aspects" of the provided data to minimize rebuilds.
Pattern: Simple Theme-Like Provider
class MyConfig extends InheritedWidget {
final String baseUrl;
const MyConfig({required this.baseUrl, required Widget child}) : super(child: child);
static MyConfig of(BuildContext context) => context.dependOnInheritedWidgetOfExactType()!;
@override bool updateShouldNotify(covariant MyConfig old) => baseUrl != old.baseUrl;
}
Guideline: Prefer InheritedWidget for stable, mostly-read-only config values; prefer Provider for mutable or complex state.
Provider and ChangeNotifier (Idiomatic Flutter)
Provider is the recommended state management solution for most Flutter apps.
Core Concept
Provider is a lightweight wrapper around InheritedWidget that integrates easily with ChangeNotifier, ValueNotifier, or custom classes. It encourages separation of concerns and testability.
Core Patterns
Provide a ChangeNotifier at the app root:
ChangeNotifierProvider(
create: (_) => CartModel(),
child: MyApp(),
)
Consume with context.watch<CartModel>() to rebuild on changes, context.read<CartModel>() to call methods without rebuilding, or Selector to rebuild only when selected fields change.
ChangeNotifier Example
class CartModel extends ChangeNotifier {
final List- _items = [];
List
- get items => List.unmodifiable(_items);
void add(Item item) {
_items.add(item);
notifyListeners();
}
}
Best Practices
- Keep ChangeNotifier focused and small; split responsibilities across multiple providers (e.g., AuthProvider, CourseProvider).
- Use
Selectororcontext.selectto avoid unnecessary rebuilds for fine-grained performance. - Dispose providers automatically by using
createat the appropriate scope; for long-lived singletons, provide them above MaterialApp.
Testing
Inject mocked or test-specific providers in widget tests to simulate app state. Unit-test ChangeNotifier methods (add/remove) and verify notifyListeners behavior indirectly via expected state changes.
ValueNotifier, Riverpod, Bloc (Overview and When to Use)
Understanding alternative state management solutions helps you choose the right tool.
ValueNotifier
Minimal observable for single-value state; useful for simple controllers (ValueListenableBuilder).
Riverpod
Provider framework with improved testability, compile-time safety, and decoupling from BuildContext; adopt when you want immutability, better scoping, and easier dependency overrides in tests.
Bloc (and Cubit)
Event-driven, structured state transitions with clear separation of events, states, and side effects; adopt for large apps with complex flows and strict state transition needs.
Decision Guide
- Small apps or prototypes: setState + Provider or ValueNotifier.
- Medium apps: Provider + ChangeNotifier or Riverpod for better testability and modularity.
- Large apps with complex logic: Consider Bloc or Riverpod + state machines for explicit transitions.
Side Effects and Async Flows
Handling side effects properly is crucial for maintainable state management.
Best Practices
- Isolate side effects (network, storage) in service classes or repository layers. Services return Futures/Streams and state notifiers call those services and update state accordingly.
- Use
asyncmethods on ChangeNotifier with clear loading/error states. - Prefer returning Result objects or using a typed state (e.g., enum {idle, loading, success, error}) to make UI logic explicit and testable.
Example
class CourseProvider extends ChangeNotifier {
bool loading = false;
List courses = [];
Future loadCourses() async {
loading = true;
notifyListeners();
try {
courses = await _api.fetchCourses();
} catch (e) {
// handle error state
} finally {
loading = false;
notifyListeners();
}
}
}
Performance and Rebuild Strategies
Optimizing rebuilds improves app performance and user experience.
Performance Tips
- Use
constwidgets where possible. - Scope providers narrowly so only widgets that need a provider rebuild when it changes.
- Use
Selectorandcontext.selectto subscribe to specific fields instead of whole objects. - Avoid passing entire large models as props to leaves if only a small field is needed; pass primitive fields or expose small getters.
Example Using Select
final itemCount = context.select((m) => m.items.length);
State Persistence and Hydration
Persisting state ensures users don't lose their work and improves app experience.
Persistence Strategies
- For small persisted preferences use SharedPreferences or secure storage for credentials/tokens.
- For larger hydrated state (cached lists, incomplete forms), persist serialized models (JSON) to local DB (sqflite) or local file and reload on startup.
- Keep persistence logic out of ChangeNotifier; inject a repository that handles read/write and exposes sync/async methods.
Startup Pattern
Use a FutureBuilder or splash/init screen while loading persisted state, then provide initialized providers to the widget tree with the loaded data.
Testing Strategies for Stateful Apps
Testing stateful apps requires careful setup and mocking.
Testing Approaches
- Unit test pure logic in services and state notifiers.
- Widget test with providers: wrap the tested widget in the appropriate Provider scope and supply fake services.
- Integration tests to validate end-to-end flows including network mocks or test backends.
- Use dependency inversion: inject API clients or repositories so tests can supply deterministic, fast doubles.
Example Widget Test Scaffold
Wrap widget under test with ChangeNotifierProvider.value(value: testModel) and pumpWidget to verify UI updates when testModel.add() is called.
Patterns and Folder Organization
Organizing code properly makes large apps maintainable.
Suggested Structure
lib/models/— data classes and JSON serializationlib/services/— API clients, repositories, persistence adapterslib/providers/— ChangeNotifier or provider declarations and factory functionslib/screens/— UI screens that consume providerslib/widgets/— presentational widgets
Design Recommendation
One provider per domain area (AuthProvider, CourseProvider). Keep providers small and cohesive. Use composition of providers in the app root.
Exercises
Practice what you've learned with these exercises:
1. Local counter with setState
Implement a simple counter screen using setState. Add a toggle that shows/hides the counter and ensure state is correctly maintained while visible. Guard async increments with mounted.
2. Cart using Provider
Build a CartModel ChangeNotifier that supports add, remove, total price, and clear. Provide it at the app root. Create a product list screen that can add items and a cart screen that observes cart updates. Use Selector to only rebuild the cart badge count where necessary.
3. Async load and error states
Implement CourseProvider that fetches courses with simulated delay and error probability. UI should show loading spinner, list on success, and retry button on error.
4. State testing
Write unit tests for CartModel (add/remove/total) and widget tests for the cart badge updating when items are added.
Session Assignment
Complete Exercises 2 and 3. Submit source files, unit tests, and a short design doc (250–400 words) explaining provider boundaries, how side effects are handled, and decisions about scoping and persistence.